Skip to content

diag: say WHY the sparse sleep bridge did nothing (#737) - #789

Merged
ryanbr merged 1 commit into
mainfrom
diag/sparse-bridge-per-pair-737
Jul 25, 2026
Merged

ryanbr merged 1 commit into
mainfrom
diag/sparse-bridge-per-pair-737

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Why

A reporter's WHOOP 5.0 night (#737 — "asleep time is incorrect, but bedtime is accurate") logged:

gate run=-1 DROPPED gate=sparseBridge sparse=true gapMin=90 runsBefore=13 runsAfter=13
gate run=1  spanMin=48  DROPPED minSleepMin=60
gate run=3  spanMin=52  DROPPED minSleepMin=60
gate run=5  spanMin=55  DROPPED minSleepMin=60
…8 of 13 runs discarded

Asleep time is the sum of the kept fragments, so it under-counts, while the in-bed span (their "bedtime") stays right — exactly the mismatch they described. bridgeSparseSleep (#308) exists to re-stitch fragments before minSleepMin, and it changed nothing — but the trace gives no reason.

Three very different causes are possible:

  1. gap over the 90-min tolerance,
  2. HR outside the sleep band across the gap,
  3. the fragments never being two adjacent sleep runs at all.

No fix can be chosen responsibly without knowing which. Loosening minSleepMin on the sparse path and bridging across active runs are mutually exclusive, and both change sleep scoring for every user — the class of change this repo's physiological-signal rule exists to gate (the withdrawn PPG→HR #194 is the precedent).

What this adds

sparseBridgeAttempts() — a per-pair explanation that mirrors the bridge's rule exactly (same adjacency walk, same gap >= 0 && gap <= sparseBridgeGapMin*60, same HR-band check):

gate sparseBridgePair pair=0 gapMin=12  hrInSleepBand=true  reason=bridged
gate sparseBridgePair pair=1 gapMin=140 hrInSleepBand=true  reason=gapTooLong

And when no pair was ever considered, it says so in words — because "fragments separated by active runs" is itself the diagnosis, and runsBefore/runsAfter cannot express it.

Tests

Pinned against the real bridge, not asserted in isolation. Every case asserts the agreement invariant — bridged-pair count must equal bridgeSparseSleep's actual run reduction — including a three-run chain, where a naive diagnostic that failed to re-walk the growing merged run would silently drift. Plus gapTooLong, hrOutOfBand, the reporter's active-run shape, and the non-sparse no-op.

Band arithmetic verified against the real constant (hrSleepBandMult = 1.05): 50 bpm vs baseline 70 → in band (≤73.5); 110 bpm vs 60 → out of band (>63.0).

Scope

Diagnostics only — no scoring change. Pure and CI-covered (StrandAnalytics), so unlike the app-target work this is genuinely verified.

Deliberately not included: any change to minSleepMin or the bridge's adjacency rule. Those wait for this trace to name the cause — and for the reporter to identify which night looked wrong (their pasted log is truncated mid-line, and the 13 runs total ~22.7 h, so they span the whole analysis window rather than one night).

A reporter's WHOOP 5.0 night logged:

  gate run=-1 DROPPED gate=sparseBridge sparse=true gapMin=90 runsBefore=13 runsAfter=13

then had 8 of 13 runs discarded by minSleepMin=60, under-counting asleep time
while the in-bed span (their 'bedtime') stayed correct - exactly the mismatch
they reported. The bridge exists to re-stitch fragments BEFORE that gate, and
it changed nothing, but the trace gave no reason. Three very different causes
are possible - gap over the 90-min tolerance, HR outside the sleep band, or
the fragments not being adjacent sleep runs at all - and no fix could be
chosen responsibly without knowing which. Loosening minSleepMin or bridging
across active runs both change sleep scoring for every user.

Add sparseBridgeAttempts(): a per-pair explanation that mirrors
bridgeSparseSleep's rule EXACTLY (same adjacency walk, same
gap >= 0 && gap <= sparseBridgeGapMin*60, same HR-band check), emitting

  gate sparseBridgePair pair=0 gapMin=12 hrInSleepBand=true reason=bridged

per considered pair - and, when NO pair was ever considered, saying so in
words, because 'fragments separated by active runs' is itself the diagnosis
and the old before/after counts could not express it.

Tests pin the diagnostic against the real bridge rather than in isolation: an
agreement invariant (bridged-pair count == the bridge's actual run reduction)
is asserted on every case, including a three-run chain where a naive
implementation that failed to re-walk the growing run would drift. Also
covered: gapTooLong, hrOutOfBand, the reporter's active-run shape, and the
non-sparse no-op.

Pure + CI-covered (StrandAnalytics). No scoring change - diagnostics only.
@ryanbr
ryanbr merged commit 0cebb2c into main Jul 25, 2026
9 checks passed
@ryanbr
ryanbr deleted the diag/sparse-bridge-per-pair-737 branch July 25, 2026 02:57
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…r#789)

A reporter's WHOOP 5.0 night logged:

  gate run=-1 DROPPED gate=sparseBridge sparse=true gapMin=90 runsBefore=13 runsAfter=13

then had 8 of 13 runs discarded by minSleepMin=60, under-counting asleep time
while the in-bed span (their 'bedtime') stayed correct - exactly the mismatch
they reported. The bridge exists to re-stitch fragments BEFORE that gate, and
it changed nothing, but the trace gave no reason. Three very different causes
are possible - gap over the 90-min tolerance, HR outside the sleep band, or
the fragments not being adjacent sleep runs at all - and no fix could be
chosen responsibly without knowing which. Loosening minSleepMin or bridging
across active runs both change sleep scoring for every user.

Add sparseBridgeAttempts(): a per-pair explanation that mirrors
bridgeSparseSleep's rule EXACTLY (same adjacency walk, same
gap >= 0 && gap <= sparseBridgeGapMin*60, same HR-band check), emitting

  gate sparseBridgePair pair=0 gapMin=12 hrInSleepBand=true reason=bridged

per considered pair - and, when NO pair was ever considered, saying so in
words, because 'fragments separated by active runs' is itself the diagnosis
and the old before/after counts could not express it.

Tests pin the diagnostic against the real bridge rather than in isolation: an
agreement invariant (bridged-pair count == the bridge's actual run reduction)
is asserted on every case, including a three-run chain where a naive
implementation that failed to re-walk the growing run would drift. Also
covered: gapTooLong, hrOutOfBand, the reporter's active-run shape, and the
non-sparse no-op.

Pure + CI-covered (StrandAnalytics). No scoring change - diagnostics only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant